深入理解 IO 多路复用
在当今数字化的时代,高性能网络服务器的设计成为了关键需求之一。当面临需要同时处理多个客户端连接和请求的场景时,传统的多线程方式由于 CPU 上下文切换的繁琐和在客户端连接众多时带来的高昂代价,并非最佳解决方案。因此,单线程处理方式逐渐成为了研究的焦点。
简单的单线程循环遍历:
在 Linux 系统中,网络连接以文件描述符的形式存在。为了用单线程处理网络连接,我们可以编写一个 while true 循环,遍历所有的文件描述符集合。如果当前文件描述符中有数据,就读取并处理;如果没有数据,则继续下一个文件描述符的判断。这种方式虽然简单粗暴地解决了单线程处理的问题,但由于判断数据是否存在的效率较低,仍有很大的优化空间。
一、Select 函数的优化
Select 函数的出现为单线程处理网络连接带来了新的思路。它同样通过一个 while true 循环不断地监听文件描述符。在准备阶段,创建 socket 服务端并生成多个文件描述符,将这些文件描述符存储在一个特定的数据结构中,并求出最大的文件描述符值。
我这里有一段select代码,在while(1)上面的代码都是在为了准备文件描述符的数组fds
sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建一个TCP套接字
memset(&addr, 0, sizeof(addr)); // 清空addr结构体,初始化地址信息
addr.sin_family = AF_INET; // 设置地址族为IPv4
addr.sin_port = htons(2000); // 设置端口号为2000,使用网络字节序
addr.sin_addr.s_addr = INADDR_ANY; // 绑定到本地所有IP地址
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)); // 将套接字绑定到指定的地址和端口
listen(sockfd, 5); // 监听端口,允许最多5个连接
for (i = 0; i < 5; i++) {
memset(&client, 0, sizeof(client)); // 清空client结构体,准备接收客户端信息
addrlen = sizeof(client); // 获取client地址结构的长度
fds[i] = accept(sockfd, (struct sockaddr*)&client, &addrlen); // 接受一个客户端连接并保存文件描述符
if (fds[i] > max) // 更新最大文件描述符,用于select
max = fds[i];
}
while (1) {
FD_ZERO(&rset); // 清空文件描述符集
for (i = 0; i < 5; i++) {
FD_SET(fds[i], &rset); // 将客户端文件描述符添加到rset集合中
}
puts("round again"); // 打印当前轮次
select(max + 1, &rset, NULL, NULL, NULL); // 使用select等待某个文件描述符准备好读 阻塞直到有数据来
for (i = 0; i < 5; i++) {
if (FD_ISSET(fds[i], &rset)) { // 检查是否有客户端发来了数据
memset(buffer, 0, MAXBUF); // 清空缓冲区
read(fds[i], buffer, MAXBUF); // 从客户端读取数据到缓冲区
puts(buffer); // 打印收到的消息
}
}
}
rset
是一个 fd_set
类型的变量,专门用于存储多个文件描述符,并传递给 select()
函数来监控其状态。fd_set
是一个位图(bitmap)结构,内部包含一系列文件描述符的集合,默认大小是1024,因为Linux默认最大支持1024个线程
bitmap存储的是01结构,例如上面有数组有5个元素,(假设是1 2 5 7 9),那么bitmap对应位上就都是1,也就是bitmap可以涵盖了fds中的所有信息
Select 函数接收多个参数,其中最重要的是读文件描述符集合、写文件描述符集合和异常文件描述符集合,以及超时时间。但在网络服务器中,我们最关心的是读文件描述符集合。Select 函数接收的参数并不是直接的文件描述符集合,而是一个 bitmap(位映射),这个 bitmap 用来表征哪些文件描述符被启用或监听。5个参数描述如下:
-
max + 1: 监控的文件描述符的范围是从0到
max
,max + 1
为上限。 -
&rset: 指向要监控的可读文件描述符集。
-
NULL: 不监控可写文件描述符。
-
**NULL **: 不监控异常文件描述符。
-
**NULL **: 没有超时,阻塞直到有文件描述符状态改变。
我们知道,程序是运行在用户态空间的,Select 函数在执行过程中,会将用户态空间的 bitmap(rset) 全量拷贝到内核态。内核负责判断每一个文件描述符(fd)是否有数据(因为内核态判断效率肯定会比用户态效率高,因为用户态判断也是在询问内核,多了一个用户态到内核态的切换)。如果没有数据,程序会处于阻塞状态;一旦有数据到来,内核会将有数据的文件描述符置位,并使 select 函数返回(不再阻塞,运行到下一行)。此时,程序会遍历文件描述 符集合,判断哪个文件描述符被置位,然后读取数据并进行处理。
所以说,使用select最有效率的一点其实就是将bitmap(rset)一次性丢到内核态,让内核态来处理,减少了每次用户态判断的消耗(因为每次用用户态来判断都要设计用户态到内核态的切换)
然而,Select 函数也存在一些缺点:
- 首先,bitmap 默认大小为 1024,虽然可以调整,但仍然有上限。
- 其次,每次循环都需要重新赋空值并重新将文件描述符加入到 bitmap 中,因为这个集合不可重用。
- 再者,从用户态切换到内核态拷贝数据仍有较大开销。
- 最后,select 函数返回时,我们只知道有文件描述符有数据,但不知道具体是哪一个或哪几个,需要再次遍历,这是一个 O (n) 的复杂度操作。
二、Poll 函数的改进
随着时间的演进,Poll 函数出现了。Poll 函数的工作原理与 Select 相似,同样是将文件描述符从用户态拷贝到内核态,让内核监听数据。但 Poll 函数采用了一种新的数据结构 ——poll fd(结构体)。
struct pollfd{
int fd;
short event;
short revents;
}
for (i = 0; i < 5; i++) { // 初始化 pollfds 结构体数组,准备监听 5 个客户端连接
memset(&client, 0, sizeof(client)); // 清空客户端的地址信息
addrlen = sizeof(client); // 获取客户端地址信息的大小
pollfds[i].fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);// 接受来自客户端的连接,并将文件描述符保存到 pollfds[i].fd
pollfds[i].events = POLLIN; // 设置感兴趣的事件类型为 POLLIN,表示关注可读事件
}
sleep(1);// 稍微等待 1 秒,避免立即进入循环
while (1) {
puts("round again"); // 输出当前轮次标记,便于调试和观察循环执行情况
poll(pollfds, 5, 50000);// 调用poll 函数监听 pollfds 数组中5个文件描述符的事件,等待最多 50000毫秒(即 50 秒)
for (i = 0; i < 5; i++) {// 遍历所有文件描述符,检查是否有可读事件发生
if (pollfds[i].revents & POLLIN) {// 如果有可读事件发生(POLLIN),则处理该事件
pollfds[i].revents = 0;// 重置 revents,清除事件状态
memset(buffer, 0, MAXBUF);// 清空缓冲区,准备读取数据
read(pollfds[i].fd, buffer, MAXBUF); // 从 pollfds[i].fd 关联的文件描述符中读取数据到 buffer 中
puts(buffer); // 输出接收到的数据
}
}
}
Poll fd 结构体有三个字段:FD 直接存储文件描述符;event 表示在意的事件,如读事件(poll in)或写事件(poll out);revents 是对问题的回馈,初始为零。当有数据到来时,内核会将 revents 置位为相应的事件。与 Select 不同的是,这里只需要恢复 revents 为零即可,而不需要像 Select 那样每次重新创建 bitmap。
Poll 函数解决了 Select 的部分缺点。首先,它突破了 bitmap1024 大小的限制,因为采用了 poll fd 数组,可以存储更多的文件描述符。其次,poll fd 可以重用,提高了效率。但 Poll 函数仍然存在与 Select 相同的问题:
- 即从用户态到内核态的切换仍有开销,
- 并且在返回时不知道具体哪些文件描述符有数据,需要遍历判断,复杂度为 O (n)。
三、Epoll 的创新
Epoll 是最新的一种多路 IO 复用函数。它分为三步:首先,用 epoll_create 创建一个 epoll 文件描述符(epfd),这个 epfd 可以理解为一个白板。然后,用 epoll_control 对 epfd 进行配置,在白板上添加文件描述符和对应的事件结构体。最后,在 while true 循环中使用 epoll_wait 等待事件发生。
在 Epoll 中,并没有共享用户态和内核态的数据,而是在epoll_ctl的时候把要监控的数据拷贝到内核态了,一次拷贝,终生受用,而select,poll每次调用都要拷贝这部分参数。epfd实际上是用户态的一块地址描述符。不再需要像 Select 和 Poll 那样进行数据拷贝,从而解决了用户态和内核态切换的开销问题。当有数据到来时,Epoll 会进行重排,将有数据的文件描述符放到最前面,并返回触发事件的总数。这样,我们只需要遍历总数个元素即可进行数据读取和处理,复杂度为 O (1)。
(用户态和内核态共享 epfd 这块内存,这句话是错误的,网上很多都这样说)
struct epoll_event events[5];// 定义 epoll 事件数组,用来保存监听的事件
int epfd = epoll_create(10); // 创建 epoll 实例,参数 10 是内核期望的监听事件数,可以忽略
for (i = 0; i < 5; i++) {// 循环处理 5 个客户端的连接
static struct epoll_event ev;// 定义 epoll_event 结构体,包含文件描述符和事件类型
memset(&client, 0, sizeof(client)); // 清空客户端地址结构
addrlen = sizeof(client);// 获取客户端地址结构大小
ev.data.fd = accept(sockfd, (struct sockaddr*)&client, &addrlen);// 接受客户端连接,返回客户端的套接字文件描述符
ev.events = EPOLLIN;// 设置监听的事件为可读(POLLIN)
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);// 注册新的文件描述符到 epoll 实例中,EPOLL_CTL_ADD 表示添加操作
}
while (1) {
puts("round again"); // 输出调试信息,表示进入了新一轮的 epoll 监听
// 等待 epoll 事件,返回就绪的文件描述符数量,最多监听 5 个事件,超时时间为 10000 毫秒(10 秒)
nfds = epoll_wait(epfd, events, 5, 10000);
for (i = 0; i < nfds; i++) {// 遍历所有有事件的文件描述符
memset(buffer, 0, MAXBUF);// 清空缓冲区,准备读取数据
read(events[i].data.fd, buffer, MAXBUF);// 从就绪的文件描述符中读取数据
puts(buffer);// 输出接收到的数据
}
}
目前,很多知名的软件都采用了 Epoll,如 Redis、Nginx 以及 Linux 下 Java 的 NIO。
四、技术背后的硬件优势及问题探讨
在多路 IO 复用的过程中,我们借助了硬件上的优势,如 DMA(直接内存访问)控制器。DMA 控制器可以在不经过 CPU 的情况下处理 IO 操作,保证了数据不会因为单线程处理而丢失。
几个思考问题:
- 固态硬盘(SSD)和机械硬盘的区别是什么?
存储介质:SSD 使用 NAND 闪存芯片进行数据存储,而 HDD 依赖于磁性盘片和机械臂的读取。
速度:SSD 没有移动部件,访问数据时不需要像 HDD 一样等待磁盘旋转和磁头定位,数据读取速度远远快于 HDD。
耐用性:由于没有机械部件,SSD 更耐冲击和震动,而 HDD 的磁盘和机械臂在震动下可能导致数据丢失或损坏。
功耗:SSD 的能耗更低,而 HDD 由于机械部分的运作,功耗较高。
噪音:SSD 完全静音,HDD 在运行时会产生转动的噪音。
价格:SSD 每 GB 成本相对较高,HDD 每 GB 成本较低,但 SSD 的价格随着技术进步在下降。
- 为什么固态硬盘会比机械硬盘快?它采用了什么样的原理?
固态硬盘之所以比机械硬盘快,主要是由于其电子存储原理。SSD 通过 NAND 闪存芯片直接访问数据,而无需依赖机械动作(如磁头定位和盘片旋转)。具体的原因有:
- 无机械延迟:HDD 需要等待磁盘旋转到正确位置并让磁头进行定位,这叫做寻道时间。而 SSD 完全不需要物理寻道,数据可以瞬时随机读取。
- 并行数据处理:SSD 内部可以同时读写多个闪存单元,支持多线程的并行操作,进一步提高了读取和写入速度。
- 低延迟:SSD 的控制器直接访问闪存单元,读取延迟非常低,响应时间快。
- 高速缓存:SSD 内部一般会集成缓存(DRAM 或 SLC 缓存),可以提高短期内读写的性能。
- 其次,哪些数据库对 SSD 进行了优化?这些优化利用了 SSD 的什么特性对数据库的读写进行了优化?
MySQL/InnoDB:
- 优化特性:MySQL 的 InnoDB 存储引擎利用 SSD 的快速随机读写能力,尤其是在涉及大量小型随机 I/O 的场景下,SSD 可以显著减少查询延迟。InnoDB 的缓冲池机制也可以更高效地利用 SSD 的并行访问能力。
- 利用特性:SSD 快速随机读写和低延迟。
PostgreSQL:
- 优化特性:PostgreSQL 可以通过调整
random_page_cost
和seq_page_cost
参数来更好地利用 SSD,降低随机访问和顺序访问的成本比例,从而充分发挥 SSD 的低延迟优势。- 利用特性:快速随机读取和写入。
MongoDB:
- 优化特性:MongoDB 对 SSD 的使用非常理想,特别是在涉及高频的写操作时。它利用了 SSD 的快速写入速度来处理大量的日志写入和数据插入。
- 利用特性:高频写操作性能和快速日志写入。
IO 多路复用技术在高性能网络服务器的设计中起着至关重要的作用。从 Select 到 Poll 再到 Epoll,每一次的演进都在不断地优化性能,解决前一代技术的缺点。同时,我们也应该关注硬件技术的发展,以及如何更好地利用硬件优势来提升软件的性能。
补充、epoll实现了共享内存问题?
看了不少博客中提到,epoll_wait返回时,对于就绪的事件,epoll使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗.那我就有一个疑问,epoll_wait函数第二参数,为什么要现在用户态分配内存,如果是共享,应该传一个指针即可,内核将它指向共享的就绪链表即可?
epoll_wait的实现~有关从内核态拷贝到用户态代码.可以看到__put_user这个函数就是内核拷贝到用户空间.分析完整个linux 2.6版本的epoll实现没有发现使用了mmap系统调用,根本不存在共享内存在epoll的实现
if (revents) {
/* 将当前的事件和用户传入的数据都copy给用户空间,
* 就是epoll_wait()后应用程序能读到的那一堆数据. */
if (__put_user(revents, &uevent->events) ||
__put_user(epi->event.data, &uevent->data)) {
/* 如果copy过程中发生错误, 会中断链表的扫描,
* 并把当前发生错误的epitem重新插入到ready list.
* 剩下的没处理的epitem也不会丢弃, 在ep_scan_ready_list()
* 中它们也会被重新插入到ready list */
list_add(&epi->rdllink, head);
return eventcnt ? eventcnt : -EFAULT;
}
代码地址:https://github.com/torvalds/linux/blob/master/fs/eventpoll.c
面试题select、poll、epoll的选择
select缺点:
- select() 检测数量有限制,最大值通常为 1024(bit),每一个比特位对应一个监听的文件描述符
- fd_set被内核修改后,不可以重用,每次都需要重置
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大(((时间复杂度是O(n))))
poll缺点:select第三四条缺点没有解决
- 每次调用select,都需要把**fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
- 每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大(((时间复杂度是O(n))))
epoll优点:epoll底层数据结构
- 红黑树增删改综合效率高
- 就绪的描述符的链表。当有的连接就绪的时候,内核会把就绪的连接放到 rdllist 链表里。这样应用进程 只需要判断链表就能找出就绪进程,而不用去遍历整棵树。